Skip to content

Add UniFFI Kotlin bindings for payjoin-ffi - #1869

Open
ram0verflow wants to merge 4 commits into
payjoin:masterfrom
ram0verflow:kotlin-bindings-pr
Open

Add UniFFI Kotlin bindings for payjoin-ffi#1869
ram0verflow wants to merge 4 commits into
payjoin:masterfrom
ram0verflow:kotlin-bindings-pr

Conversation

@ram0verflow

Copy link
Copy Markdown
Pull Request Checklist

Please confirm the following before requesting review:

What

UniFFI Kotlin/JVM bindings for payjoin-ffi, plus a BIP77 v2 round-trip
integration test.

Layout matches python/csharp:

  • payjoin-ffi/kotlin/scripts/generate_bindings.sh
  • payjoin-ffi/kotlin/contrib/test.sh
  • PAYJOIN_FFI_FEATURES (default _test-utils; empty string is passed
    through) and PAYJOIN_FFI_PROFILE (default devtarget/debug)

Generated sources are not committed (gitignored under
kotlin/src/main/kotlin/org/). Generate is a build step.

CI runs inside nix develop .#kotlin (JDK 21, MSRV, nixpkgs bitcoind),
matching csharp/python/dart.

Unit tests (15) cover URIs, persistence, cancel, and validation — the
same count as the Python unit suite. The second commit adds the v2
integration test (16 Gradle tests on HEAD).

Why [bindings.kotlin.rename]

Generated UniFFI objects implement Disposable and AutoCloseable, so
close() drops the Rust handle. Payjoin also exports protocol close() on
ReceiverPendingFallback, SenderPendingFallback, and the four JSON
session persister traits. Kotlin cannot overload on return type.

The table is Kotlin-only. It maps those protocol methods to
closeSession(). No Rust API change. Other language bindings keep close.

Integration test

IntegrationTests.kt drives a full BIP77 v2 round trip, sender and
receiver, against the in-process directory, OHTTP relay, and a real
bitcoind. Under the kotlin nix shell, BITCOIND_EXE comes from nixpkgs
and BITCOIND_SKIP_DOWNLOAD=1. Outside the shell, corepc-node may
download Bitcoin Core into a temp regtest dir.

On Linux, v2ToV2Payjoin completes in about 4s.

TestHttp trusts only the directory's rcgen cert and proxies through the
OHTTP relay.

Wrapper pin

Gradle 9.1.0 wrapper is pinned with distributionSha256Sum.
validateDistributionUrl only checks the URL.

Release / publish

Not published anywhere. No pack, Maven, signing, or attestation jobs.

The workflow does not trigger on payjoin-kotlin-* tags. python.yml's
tag trigger runs wheel build, PyPI publish, and GitHub release assets.
Until Kotlin has something to publish, a tag trigger would only re-run
tests.

Follow-ups (out of this PR)

  • No ktfmt / treefmt registration for .kt
  • No Windows CI (same as python/dart/javascript)

Disclosure

This PR was written with Cursor Cloud Agent (Grok 4.6).

  • Code: Kotlin Gradle project, generate/test scripts, nix kotlin
    shell, CI workflow, unit tests, and the v2 integration test were
    AI-generated and then hand-reviewed against the Python suite and a
    known-good round trip (~4s).
  • PR body: AI-drafted (this text).

Disclosure: co-authored by Cursor Grok 4.6

@chavic

chavic commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

@ram0verflow Thanks, I'll take a look today

@chavic
chavic requested review from chavic and removed request for DanGould, benalleng and spacebear21 September 10, 2026 14:30
@coveralls

coveralls commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 34676187573

Coverage remained the same at 86.646%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 16549
Covered Lines: 14339
Line Coverage: 86.65%
Coverage Strength: 341.69 hits per line

💛 - Coveralls

@chavic

chavic commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

@ram0verflow it looks like you're hitting an unrelated probelm, that's fixed on master, you want to rebase

@chavic chavic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments below.

Comment thread payjoin-ffi/kotlin/build.gradle.kts
Comment thread payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/UriTests.kt Outdated
Comment thread payjoin-ffi/kotlin/CONTRIBUTING.md Outdated

@caarloshenriq caarloshenriq left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK.

Kotlin/JVM bindings alongside the other four is a good addition, and the layout mirrors python/csharp closely enough that it should be easy to keep in sync

Comment thread payjoin-ffi/kotlin/CONTRIBUTING.md Outdated
Comment thread payjoin-ffi/kotlin/README.md Outdated
Comment thread payjoin-ffi/kotlin/build.gradle.kts Outdated
@ram0verflow

Copy link
Copy Markdown
Author

will fix and update

@chavic chavic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more comments on the tests.

class UriTests {
@Test
fun urlEncodedPayjoinParameter() {
val uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a full Bitcoin URI with a percent-encoded pj value here, then assert the address, amount and complete decoded endpoint? The current fixture doesn't test encoding, and checking only the hostname would miss a dropped query. The other bindings have the same gap.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I’ll use a percent-encoded pj and assert address, amount, and the full decoded endpoint.


@Test
fun inputPairRejectsInvalidOutpoint() {
assertFailsWith<InputPairException> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this expect InputPairException.InvalidOutPoint specifically, like C# does? I replaced deadbeef with a valid txid and this still passed because the missing UTXO information throws InvalidPsbtInput.

import kotlin.test.Test
import kotlin.test.assertFailsWith

class ValidationTests {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we bring over the amount and fee-rate overflow tests from Python/C# too, including assertions on the nested error variants? Those would check Kotlin's unsigned values and error mapping across FFI.

val state = replayReceiverEventLog(persister).state()
assertIs<ReceiveSession.Initialized>(state)
assertFalse(persister.closed)
persister.closeSession()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling persister.closeSession() directly only tests the Kotlin helper. Can we assert persister.closed after closing through the Rust session in the cancellation tests, for both sync and async? That would check the renamed callback actually gets called.

@chavic chavic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more comments.


# ktlint is optional; --no-format keeps generate working without it.
cargo run "${FEATURE_ARGS[@]}" --profile dev -p payjoin-ffi --bin uniffi-bindgen -- generate \
--library "../target/$TARGET_PROFILE_DIR/$LIBNAME" \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use Cargo’s actual output path here and for the copy below? With CARGO_TARGET_DIR set, Cargo builds there but this still reads ../target/.

if (txOut is JsonNull) return false
val scriptHex = txOut.jsonObject.getValue("scriptPubKey").jsonObject.getValue("hex").jsonPrimitive.content
IsScriptOwnedCallback(connection).callback(HexFormat.of().parseHex(scriptHex))
} catch (_: Exception) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we let RPC errors fail the test here? Returning false treats an RPC failure as “input not owned,” so a broken ownership check goes unnoticed.

Comment thread payjoin-ffi/kotlin/build.gradle.kts Outdated
tasks.test {
useJUnitPlatform()
val libDir = layout.projectDirectory.dir("lib").asFile
val native = listOf("libpayjoin_ffi.so", "libpayjoin_ffi.dylib", "payjoin_ffi.dll")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we select the native library for the current OS? If this checkout is used on macOS and in a Linux container, both files can remain in lib/, and this always picks the .so first.

- name: Set up nix
uses: ./.github/actions/setup-nix
- name: "Build and test"
run: nix develop .#kotlin -c bash ./payjoin-ffi/kotlin/contrib/test.sh

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can CI also generate with PAYJOIN_FFI_FEATURES= and compile the resulting Kotlin bindings? This job only exercises _test-utils, so the documented production configuration isn’t covered.

ram0verflow and others added 4 commits September 12, 2026 09:46
Generate with in-tree uniffi-bindgen --language kotlin, siloed
like Python (no extra cargo feature). Rename protocol close to
closeSession in Kotlin only so AutoCloseable still works.

Pin the Gradle 9.1.0 wrapper with distributionSha256Sum;
validateDistributionUrl only checks the download URL.

Add a kotlin nix dev shell (JDK 21, MSRV, bitcoind) and run CI
inside it, matching the other language bindings.

Unit tests cover URIs, persistence, cancel, and validation.
Drive a full BIP77 round trip against the in-process directory, OHTTP
relay, and bitcoind, mirroring the Python suite's RPC sequence and
assertions. TestHttp trusts only the self-signed rcgen directory cert
so HTTPS through the relay succeeds in the JVM.

Parse bitcoind RPC responses with kotlinx-serialization-json.
Honor CARGO_TARGET_DIR, load the OS-native library, and assert
percent-encoded URIs, overflow variants, and persister close via Rust.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants